Micron Document
🎖️GitЯра🎖️

Commit 4f86b91973784e4ec05b9a5c7b2dc396ed4848b8


Parents : 492afbd
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T20:10:40Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T20:10:40Z

fix(analytics): cover local firmware sideloads and report message_send in the foreground (#6660)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
index 1812661ea3..f67bd0e6e6 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
@@ -28,6 +28,7 @@ import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl
@@ -54,6 +55,13 @@ class CoreRepositoryModule {
@Provided radioController: RadioController,
@Provided homoglyphEncodingPrefs: HomoglyphPrefs,
@Provided messageQueue: MessageQueue,
- ): SendMessageUseCase =
- SendMessageUseCaseImpl(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
+ @Provided analytics: PlatformAnalytics,
+ ): SendMessageUseCase = SendMessageUseCaseImpl(
+ nodeRepository,
+ packetRepository,
+ radioController,
+ homoglyphEncodingPrefs,
+ messageQueue,
+ analytics,
+ )
}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index 3923d4a785..7e6d94f06b 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -29,6 +29,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.proto.Config
import kotlin.random.Random
@@ -60,6 +61,7 @@ class SendMessageUseCaseImpl(
private val radioController: RadioController,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val messageQueue: MessageQueue,
+ private val analytics: PlatformAnalytics,
) : SendMessageUseCase {
/**
@@ -130,6 +132,12 @@ class SendMessageUseCaseImpl(
// Enqueue for durable transmission via the platform-specific queue
messageQueue.enqueue(packetId)
+ // Reported here rather than at transmission: the queue worker can run long after the RUM
+ // session ended, and re-runs the send on retry.
+ analytics.trackAction(
+ "message_send",
+ mapOf("num_bytes" to finalMessageText.length, "is_reply" to (replyId != null)),
+ )
} catch (ex: Exception) {
Logger.e(ex) { "Failed to enqueue message packet" }
throw ex

diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
index 95e716654a..2e6ed971e0 100644
--- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
@@ -18,12 +18,14 @@ package org.meshtastic.core.repository.usecase
import dev.mokkery.MockMode
import dev.mokkery.mock
+import dev.mokkery.verify
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.testing.FakeAppPreferences
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
@@ -40,6 +42,7 @@ class SendMessageUseCaseTest {
private lateinit var radioController: FakeRadioController
private lateinit var appPreferences: FakeAppPreferences
private lateinit var messageQueue: MessageQueue
+ private lateinit var analytics: PlatformAnalytics
private lateinit var useCase: SendMessageUseCase
@BeforeTest
@@ -49,6 +52,7 @@ class SendMessageUseCaseTest {
radioController = FakeRadioController()
appPreferences = FakeAppPreferences()
messageQueue = mock(MockMode.autofill)
+ analytics = mock(MockMode.autofill)
useCase =
SendMessageUseCaseImpl(
@@ -57,6 +61,7 @@ class SendMessageUseCaseTest {
radioController = radioController,
homoglyphEncodingPrefs = appPreferences.homoglyph,
messageQueue = messageQueue,
+ analytics = analytics,
)
}
@@ -75,6 +80,20 @@ class SendMessageUseCaseTest {
radioController.sentSharedContacts.size shouldBe 0
}
+ @Test
+ fun `invoke reports a message_send analytics action`() = runTest {
+ // Arrange
+ val ourNode = Node(num = 1, user = User(id = "!1234"))
+ nodeRepository.setOurNode(ourNode)
+ appPreferences.homoglyph.setHomoglyphEncodingEnabled(false)
+
+ // Act
+ useCase("Hello", "0${NodeAddress.ID_BROADCAST}", null)
+
+ // Assert
+ verify { analytics.trackAction("message_send", mapOf("num_bytes" to 5, "is_reply" to false)) }
+ }
+
@Test
fun `invoke with direct message to older firmware triggers favoriteNode`() = runTest {
// Arrange

diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
index 0d5c43d388..ced2ed140a 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
@@ -62,13 +62,12 @@ internal class MessagingControllerImpl(
dataHandler.value.rememberDataPacket(packet, myNodeNum, false)
val bytes = packet.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", packet.dataType))
- val actionName =
- when (packet.dataType) {
- PortNum.TEXT_MESSAGE_APP.value -> "message_send"
- PortNum.WAYPOINT_APP.value -> "waypoint_send"
- else -> "data_send"
- }
- analytics.trackAction(actionName, mapOf("port_num" to packet.dataType, "num_bytes" to bytes.size))
+ // Text messages report their own action from SendMessageUseCase, where the user acted; this path runs on
+ // the send-queue worker and re-runs on retry, so counting them here would double up.
+ if (packet.dataType != PortNum.TEXT_MESSAGE_APP.value) {
+ val actionName = if (packet.dataType == PortNum.WAYPOINT_APP.value) "waypoint_send" else "data_send"
+ analytics.trackAction(actionName, mapOf("port_num" to packet.dataType, "num_bytes" to bytes.size))
+ }
}
override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) {

diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 3a8672d6b3..61c594b6bb 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -320,12 +320,13 @@ class FirmwareUpdateViewModel(
}
}
- fun startUpdate() {
- val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
- val release = currentState.release ?: return
- // Explicit mapping instead of class-name reflection: FirmwareUpdateMethod is R8-obfuscated in release.
+ /**
+ * Emitted from every path that begins a flash, so the RUM action counts local-file sideloads alongside release
+ * updates. The method label is mapped explicitly because [FirmwareUpdateMethod] is obfuscated in release builds.
+ */
+ private fun trackUpdateStart(state: FirmwareUpdateState.Ready, releaseId: String) {
val updateMethod =
- when (currentState.updateMethod) {
+ when (state.updateMethod) {
FirmwareUpdateMethod.Usb -> "usb"
FirmwareUpdateMethod.Ble -> "ble"
FirmwareUpdateMethod.Wifi -> "wifi"
@@ -333,12 +334,14 @@ class FirmwareUpdateViewModel(
}
analytics.trackAction(
"firmware_update_start",
- mapOf(
- "update_method" to updateMethod,
- "is_recovery" to currentState.isRecovery,
- "release_version" to release.id,
- ),
+ mapOf("update_method" to updateMethod, "is_recovery" to state.isRecovery, "release_version" to releaseId),
)
+ }
+
+ fun startUpdate() {
+ val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
+ val release = currentState.release ?: return
+ trackUpdateStart(currentState, release.id)
if (currentState.isRecovery) {
startRecoveryUpdate(currentState, release)
} else {
@@ -727,6 +730,7 @@ class FirmwareUpdateViewModel(
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
return
}
+ trackUpdateStart(currentState, LOCAL_RELEASE_ID)
originalDeviceAddress = radioPrefs.devAddr.value
updateJob?.cancel()

diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index df60d59a70..6e836147af 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -39,6 +39,7 @@ import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
@@ -68,6 +69,7 @@ class FirmwareUpdateIntegrationTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val stableRelease = FirmwareRelease(id = "1", title = "2.5.0", zipUrl = "url", releaseNotes = "")
private val hardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
@@ -115,7 +117,7 @@ class FirmwareUpdateIntegrationTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
- mock(MockMode.autofill),
+ analytics,
)
@Test

diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index ef6581810f..b5e6930983 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -23,6 +23,7 @@ import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
+import dev.mokkery.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -42,6 +43,7 @@ import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -76,6 +78,7 @@ class FirmwareUpdateViewModelTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private lateinit var viewModel: FirmwareUpdateViewModel
@@ -138,7 +141,7 @@ class FirmwareUpdateViewModelTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
hiddenFeaturesUnlock,
- mock(MockMode.autofill),
+ analytics,
)
@Test
@@ -192,6 +195,28 @@ class FirmwareUpdateViewModelTest {
assertEquals(Res.string.firmware_update_battery_low, error.res)
}
+ @Test
+ fun `startUpdate reports a firmware_update_start action with the release version`() = runTest {
+ // isBle() checks devAddr.value?.startsWith("x"), so use a BLE-prefixed address
+ every { radioPrefs.devAddr } returns MutableStateFlow("x1234abcd")
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val currentState = viewModel.state.value
+ assertIs<FirmwareUpdateState.Ready>(currentState)
+ assertIs<FirmwareUpdateMethod.Ble>(currentState.updateMethod)
+
+ viewModel.startUpdate()
+ advanceUntilIdle()
+
+ verify {
+ analytics.trackAction(
+ "firmware_update_start",
+ mapOf("update_method" to "ble", "is_recovery" to false, "release_version" to "1"),
+ )
+ }
+ }
+
@Test
fun `startUpdate transitions to Success if manager returns Success`() = runTest {
advanceUntilIdle()

diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index 59058b31dd..00450afd12 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -24,6 +24,7 @@ import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
+import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.atLeast
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
@@ -46,6 +47,7 @@ import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -82,6 +84,7 @@ class FirmwareUpdateViewModelFileTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private lateinit var viewModel: FirmwareUpdateViewModel
@@ -137,7 +140,7 @@ class FirmwareUpdateViewModelFileTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
- mock(MockMode.autofill),
+ analytics,
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
@@ -196,6 +199,41 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
}
+ @Test
+ fun `confirmLocalFirmwareFile reports a firmware_update_start action for the local file`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
+ advanceUntilIdle()
+ viewModel.confirmLocalFirmwareFile()
+ advanceUntilIdle()
+
+ verify {
+ analytics.trackAction(
+ "firmware_update_start",
+ mapOf("update_method" to "usb", "is_recovery" to false, "release_version" to "local"),
+ )
+ }
+ }
+
+ @Test
+ fun `confirmLocalFirmwareFile with BLE and invalid address reports no analytics action`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("xnot-a-mac-address")
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
+ advanceUntilIdle()
+ viewModel.confirmLocalFirmwareFile()
+ advanceUntilIdle()
+
+ verify(exactly(0)) { analytics.trackAction("firmware_update_start", any()) }
+ }
+
@Test
fun `confirmLocalFirmwareFile starts update after pending selection`() = runTest {
// Serial nRF52 → USB method (no BLE address validation)

Served by rngit 1.5.0 - Generated in 0.16s